iT邦幫忙

2022 iThome 鐵人賽

DAY 13
0
Modern Web

就是要搞懂 JavaScript 啦!系列 第 13

Day13 提升:為什麼要提升?留在原地不好嗎?

  • 分享至 

  • xImage
  •  

函式宣告

延續上一篇的話題,我們來看看這段程式碼:

console.log(a);
var a;
function a(){
  console.log("JavaScript!")
}

如果你已經把它放進 chrome 執行過,那你會知道,它最後印出的是 ƒ a(){ console.log("JavaScript!") },識別字 a 儲存的內容是函式 a 而不是變數 a

以上是傳入 chrome 105.0.5195.127 的結果,如果是跑 Node.js 就只會顯示為 [Function: a],其他瀏覽器也可能顯示不同內容。

所以是後來宣告的變數覆蓋了前面嗎?

這裡來測試看看:

console.log(a);
function a(){
  console.log("JavaScript!")
}
var a;

答案一樣是 ƒ a(){ ... }

到這裡感覺有點思路了......沒錯,在提升時,如果遇到相同識別字,函式宣告優先於變數宣告,也就是前者會覆蓋後者,但後者並不會覆蓋前者。

除此之外,函式宣告和變數宣告有什麼不同呢?來看看以下例子:

console.log(foo) // undefined
var foo = "Java"

bar() // JavaScript
function bar(){
  console.log("JavaScript")
}

按照前面變數提升的邏輯,程式執行 bar() 的時候,應該要顯示 undefined 才對,因為識別字宣告在編譯時期被提升了,而其他部分會留在原地......但在函式身上顯然不是這麼做的。

關於這點,在文章開頭其實有點小小的提示。我們回到這段代碼:

console.log(a);
var a;
function a(){
  console.log("JavaScript!")
}

如果使用 chrome 執行,得到的答案是:

ƒ a(){
  console.log("JavaScript!")
}

沒錯,程式打印出了函式 a 的完整內容,也就是說,函式宣告在編譯時期是把整個函式塊存進記憶體,體現出來的結果就是整段函式宣告被「提升」了,也就是這樣:

var a;
function a(){
  console.log("JavaScript!")
}
console.log(a);

另外函式宣告會覆蓋變數宣告,所以 var a 等同無作用的程式碼,這就是函式宣告的秘密!

這裡同時可以注意到的另一點是:函式內部的作用域,直到函式被調用之前都不會解析。

在編譯時期,程式只是把整塊函式搬進記憶體中做了「提升」,並不理會函式內部的宣告。畢竟函式本身的作用域是封閉的,是否在編譯時期處理內部宣告,對外部作用域並沒有影響,如果最後這個函式根本沒用到,那提前處理完全是白費工。

函式宣告 v.s. 函式表達式

在之前討論函式作用域的文章中,有做過函式宣告與函式表達式的比較,這兩者雖然都定義了函式,但在提升時的表現卻大不相同,這裡就讓我們檢視一下:

funDeclaration(); // 我是一個函式宣告
funExpression(); // funExpression is not a function

function funDeclaration() {
  console.log("我是一個函式宣告");
}

var funExpression = function () {
  console.log("我是一個函式表達式")
}

行內函式表達式的本質是將一個函式物件賦值給變數,從以上程式碼可以看到,將兩者都在宣告前呼叫,funDeclaration 由於擁有函式提升,所以能夠正常執行,但 funExpression 在第二行被呼叫時,仍處於被提升後預設值為 undefined 的狀態,還未執行賦值,根本無法作為函式調用。

參數的「提升」?

好吧,或許你已經猜到了,參數其實並沒有「提升」的現象,它一開始就位於作用域的最前面,根本不需要再升。

但是,參數也同樣是廣義變數的一種,在編譯時期就會被放進記憶體中,那麼當它與函式宣告或變數宣告衝突時會發生什麼事?

這裡來跑個實際的例子:

function foo(bar) {
  console.log(bar);
  var bar = 5;
  console.log(bar);
}
foo("Hello");

程式最後印出了 Hello5,因此不難猜到,在同個作用域中,參數的優先級大於變數宣告。

那再加入函式呢?

function foo(bar) {
  console.log(bar);
  var bar = 5;
  function bar() { }
  console.log(bar);
}
foo("Hello");

程式的執行結果,第一個 bar 印出 function bar(){},第二個印出 5

在這裡,函式宣告的優先級又大於參數,從以上可以知道,這三者的優先順序是這樣的:

函式宣告 > 參數 > 變數宣告


角色到齊:變數、函式、參數

因為詳細解說實際執行內容會衍伸得太複雜,想了解作用域解析的詳細執行內容,可以參考這篇這篇文章,這裡簡單總結解析作用域識別字的步驟:

  1. 如果有參數的話,將參數識別字放入記憶體
  2. 將函式宣告放入記憶體,如果遇到同名識別字則覆蓋
  3. 將變數宣告放入記憶體,但如果遇到同名識別字,則忽略這個變數宣告

以上又能夠總結出兩條簡單的原則:

  • 函式會覆蓋所有重複識別字
  • 變數覆蓋不了同名識別字

這些規則確定了作用域解析的最終結果,在 JS 編譯完畢,準備開始執行之前,作用域內的所有變數會以這樣的規則存放於記憶體中。

那麼,現在來看個實際例子:

var foo = "global";
function doThings(foo) {
  console.log("foo1:", foo);
  function foo(foo) {
    console.log("foo2:", foo);
    var foo = "inner";
    console.log("foo3:", foo);
  }
  foo("from doThings");
  var foo = "doThings";
  console.log("foo4:", foo);
}
doThings("from global");
console.log("foo5:", foo);

以上的程式碼會打印出什麼呢?公布答案前,這裡留一段防雷線,保留思考時間。
.
.
.
.
.
.
.
.
.
.
答案如下(使用 Node 16):
foo1: [Function: foo]
foo2: from doThings
foo3: inner
foo4: doThings
foo5: global

這裡來仔細看下它們分別發生了什麼:

  • foo1:解析 function doThings 的作用域,找到 doThings 作用域內的三個 foo 宣告:參數、函式、變數。函式 foo 覆蓋了其他兩者,最後輸出函式 foo
  • foo2、foo3:解析 function foo 的作用域,找到參數 foo,找到變數宣告 foo,參數優先於變數,foo2 打印出 from doThings 。接下去執行 foo = "inner";,foo3 打印出 inner
  • foo4:解析 doThings 作用域,在 doThings 作用域內有兩個 foo 宣告,函式 foo 覆蓋了變數 foo,此時 foo 的內容是函式。來到執行時期,執行到 foo = "doThings";foo 的內容被覆蓋為 doThings,最後打印出 doThings
  • foo5:搜尋全域作用域,找到變數 foofoo 被賦值為字串 global,打印出 global(由於每一層作用域都有宣告 var 變數,內部的 LHS 被遮蔽了,所以並沒有影響到最外層的 foo)。

提升的好處

前面已經說明過,所謂的提升,實際上是 JS 在編譯時期就處理好所有宣告,將所有變數事先存入記憶體,並確定每個作用域的存取規則。

所以說,為什麼要有這樣的規定?函式宣告又一定要優先於變數宣告不可嗎?或者說,到底為什麼需要提升呢?

首先關於優先序最高的函式提升,創作者給出的答案是:

提升函式宣告可以讓函式得以在宣告之前調用,這解決了互相遞歸的問題。

因為如果必定要宣告後才能調用,那互相遞歸的函式就需要達成「彼此都宣告在對方之前」這點才能做到。但如果在最一開始就把函式都提升了,那就沒有這個問題了。

這就是函式宣告提升的原因,至於變數宣告的提升......就有點尷尬了。

總結來說,它算是提升函式宣告時不小心跟著提升的,是不經意(unintended)導致的結果(所以在 ES6 時新增了補丁 let )。

當然,我們也可以說有了變數宣告的提升,在實際執行的時候就能夠省去這個步驟.......但根據作者的說法,這並不是最初想要達成的結果,而是提升函式的副作用。

以下摘錄 JS 創作者本人 Brendan Eich 的 twitter 原文

"function hoisting allows top-down program decomposition, 'let rec' for free, call before declare; var hoisting tagged along."
"var hoisting was thus unintended consequence of function hoisting, no block scope, JS as a 1995 rush job. ES6 'let' may help."
──BrendanEich Oct 15, 2014

總結來講......既然有了 letconst,就讓我們忘掉 var 這段歷史吧。


參考資料


上一篇
Day12 提升:到底是在提什麼升什麼啦!
下一篇
Day14 閉包:服務生!麻煩整個打包帶走
系列文
就是要搞懂 JavaScript 啦!73
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言